import _ from 'lodash'
import url from 'url'
import ServerlessError from '../../../../../serverless-error.js'
import utils from '@serverlessinc/sf-core/src/utils.js'

const { log, style } = utils

const originLimits = { maxTimeout: 30, maxMemorySize: 10240 }
const viewerLimits = { maxTimeout: 5, maxMemorySize: 128 }

class AwsCompileCloudFrontEvents {
  constructor(serverless, options) {
    this.serverless = serverless
    this.options = options
    this.provider = this.serverless.getProvider('aws')
    this.lambdaEdgeLimits = {
      'origin-request': originLimits,
      'origin-response': originLimits,
      'viewer-request': viewerLimits,
      'viewer-response': viewerLimits,
      default: viewerLimits,
    }
    this.cachePolicies = new Set()

    const originObjectSchema = {
      type: 'object',
      properties: {
        ConnectionAttempts: { type: 'integer', minimum: 1, maximum: 3 },
        ConnectionTimeout: { type: 'integer', minimum: 1, maximum: 10 },
        CustomOriginConfig: {
          type: 'object',
          properties: {
            HTTPPort: { type: 'integer', minimum: 0, maximum: 65535 },
            HTTPSPort: { type: 'integer', minimum: 0, maximum: 65535 },
            OriginKeepaliveTimeout: {
              type: 'integer',
              minimum: 1,
              maximum: 60,
            },
            OriginProtocolPolicy: {
              enum: ['http-only', 'match-viewer', 'https-only'],
            },
            OriginReadTimeout: { type: 'integer', minimum: 1, maximum: 60 },
            OriginSSLProtocols: {
              type: 'array',
              items: { enum: ['SSLv3', 'TLSv1', 'TLSv1.1', 'TLSv1.2'] },
            },
          },
          additionalProperties: false,
          required: ['OriginProtocolPolicy'],
        },
        DomainName: {
          anyOf: [{ type: 'string' }, { $ref: '#/definitions/awsCfFunction' }],
        },
        OriginAccessControlId: {
          anyOf: [{ type: 'string' }, { $ref: '#/definitions/awsCfFunction' }],
        },
        OriginCustomHeaders: {
          type: 'array',
          items: {
            type: 'object',
            properties: {
              HeaderName: { type: 'string' },
              HeaderValue: { type: 'string' },
            },
            additionalProperties: false,
            required: ['HeaderName', 'HeaderValue'],
          },
        },
        OriginPath: { type: 'string' },
        S3OriginConfig: {
          type: 'object',
          properties: {
            OriginAccessIdentity: {
              anyOf: [
                {
                  type: 'string',
                  pattern: '^origin-access-identity/cloudfront/.+',
                },
                { $ref: '#/definitions/awsCfFunction' },
              ],
            },
          },
          additionalProperties: false,
        },
      },
      additionalProperties: false,
      required: ['DomainName'],
      oneOf: [
        { required: ['CustomOriginConfig'] },
        { required: ['S3OriginConfig'] },
      ],
    }

    const behaviorObjectSchema = {
      type: 'object',
      properties: {
        AllowedMethods: {
          anyOf: [
            {
              type: 'array',
              uniqueItems: true,
              minItems: 2,
              items: { enum: ['GET', 'HEAD'] },
            },
            {
              type: 'array',
              uniqueItems: true,
              minItems: 3,
              items: { enum: ['GET', 'HEAD', 'OPTIONS'] },
            },
            {
              type: 'array',
              uniqueItems: true,
              minItems: 7,
              items: {
                enum: [
                  'GET',
                  'HEAD',
                  'OPTIONS',
                  'PUT',
                  'PATCH',
                  'POST',
                  'DELETE',
                ],
              },
            },
          ],
        },
        CachedMethods: {
          anyOf: [
            {
              type: 'array',
              uniqueItems: true,
              minItems: 2,
              items: { enum: ['GET', 'HEAD'] },
            },
            {
              type: 'array',
              uniqueItems: true,
              minItems: 3,
              items: { enum: ['GET', 'HEAD', 'OPTIONS'] },
            },
          ],
        },
        CachePolicyId: { type: 'string' },
        Compress: { type: 'boolean' },
        FieldLevelEncryptionId: { type: 'string' },
        OriginRequestPolicyId: {
          anyOf: [{ type: 'string' }, { $ref: '#/definitions/awsCfFunction' }],
        },
        ResponseHeadersPolicyId: {
          anyOf: [{ type: 'string' }, { $ref: '#/definitions/awsCfFunction' }],
        },
        SmoothStreaming: { type: 'boolean' },
        TrustedSigners: { type: 'array', items: { type: 'string' } },
        ViewerProtocolPolicy: {
          enum: ['allow-all', 'redirect-to-https', 'https-only'],
        },
        TrustedKeyGroups: {
          type: 'array',
          items: {
            anyOf: [{ type: 'string' }, { $ref: '#/definitions/awsCfRef' }],
          },
        },
        MaxTTL: { type: 'number' },
        MinTTL: { type: 'number' },
        DefaultTTL: { type: 'number' },
        ForwardedValues: {
          type: 'object',
          properties: {
            Cookies: {
              anyOf: [
                {
                  type: 'object',
                  properties: {
                    Forward: { enum: ['all', 'none'] },
                  },
                  additionalProperties: false,
                  required: ['Forward'],
                },
                {
                  type: 'object',
                  properties: {
                    Forward: { const: 'whitelist' },
                    WhitelistedNames: {
                      type: 'array',
                      items: { type: 'string' },
                    },
                  },
                  additionalProperties: false,
                  required: ['Forward', 'WhitelistedNames'],
                },
              ],
            },
            Headers: { type: 'array', items: { type: 'string' } },
            QueryString: { type: 'boolean' },
            QueryStringCacheKeys: { type: 'array', items: { type: 'string' } },
          },
          additionalProperties: false,
          required: ['QueryString'],
        },
      },
      additionalProperties: false,
    }

    this.serverless.configSchemaHandler.defineFunctionEvent(
      'aws',
      'cloudFront',
      {
        type: 'object',
        properties: {
          behavior: behaviorObjectSchema,
          cachePolicy: {
            type: 'object',
            properties: {
              id: { $ref: '#/definitions/awsCfInstruction' },
              name: { type: 'string', minLength: 1 },
            },
            oneOf: [{ required: ['id'] }, { required: ['name'] }],
            additionalProperties: false,
          },
          eventType: {
            enum: [
              'viewer-request',
              'origin-request',
              'origin-response',
              'viewer-response',
            ],
          },
          isDefaultOrigin: { type: 'boolean' },
          includeBody: { type: 'boolean' },
          origin: {
            anyOf: [{ type: 'string', format: 'uri' }, originObjectSchema],
          },
          // Allowed characters reference:
          // https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/distribution-web-values-specify.html#DownloadDistValuesPathPattern
          // Still note it doesn't reference "?" character, which appears in prior examples,
          // Hence it's now included in this regex
          pathPattern: {
            type: 'string',
            pattern: '^([A-Za-z0-9_.*?$/~"\'@:+-]|&amp;)+$',
          },
        },
        additionalProperties: false,
      },
    )

    this.hooks = {
      'package:initialize': async () => this.validate(),
      'before:package:compileFunctions': async () => this.prepareFunctions(),
      'package:compileEvents': () => {
        this.compileCloudFrontCachePolicies()
        this.compileCloudFrontEvents()
      },
      'before:remove:remove': async () => this.logRemoveReminder(),
    }
  }

  logRemoveReminder() {
    if (this.serverless.processedInput.commands[0] === 'remove') {
      let isEventUsed = false
      const funcKeys = this.serverless.service.getAllFunctions()
      if (funcKeys.length) {
        isEventUsed = funcKeys.some((funcKey) => {
          const func = this.serverless.service.getFunction(funcKey)
          return (
            func.events &&
            func.events.find((e) => Object.keys(e)[0] === 'cloudFront')
          )
        })
      }
      if (isEventUsed) {
        const message = [
          "Don't forget to manually remove your Lambda@Edge functions ",
          'once the CloudFront distribution removal is successfully propagated!',
        ].join('')
        log.warning(message)
      }
    }
  }

  validate() {
    this.serverless.service.getAllFunctions().forEach((functionName) => {
      const functionObj = this.serverless.service.getFunction(functionName)
      functionObj.events.forEach(({ cloudFront }) => {
        if (!cloudFront) return
        const { eventType = 'default' } = cloudFront
        const { maxMemorySize, maxTimeout } = this.lambdaEdgeLimits[eventType]
        if (functionObj.memorySize && functionObj.memorySize > maxMemorySize) {
          throw new ServerlessError(
            `"${functionName}" memorySize is greater than ${maxMemorySize} which is not supported by Lambda@Edge functions of type "${eventType}"`,
            'LAMBDA_EDGE_UNSUPPORTED_MEMORY_SIZE',
          )
        }
        if (functionObj.timeout && functionObj.timeout > maxTimeout) {
          throw new ServerlessError(
            `"${functionName}" timeout is greater than ${maxTimeout} which is not supported by Lambda@Edge functions of type "${eventType}"`,
            'LAMBDA_EDGE_UNSUPPORTED_TIMEOUT_VALUE',
          )
        }
      })
    })
  }

  prepareFunctions() {
    // Lambda@Edge functions need to be versioned
    this.serverless.service.getAllFunctions().forEach((functionName) => {
      const functionObj = this.serverless.service.getFunction(functionName)
      if (functionObj.events.find((event) => event.cloudFront)) {
        // ensure that functions are versioned
        Object.assign(functionObj, { versionFunction: true })
        // set the maximum memory size if not explicitly configured
        if (!functionObj.memorySize) {
          Object.assign(functionObj, { memorySize: 128 })
        }
        // set the maximum timeout if not explicitly configured
        if (!functionObj.timeout) {
          Object.assign(functionObj, { timeout: 5 })
        }
      }
    })
  }

  compileCloudFrontCachePolicies() {
    const userConfig = this.serverless.service.provider.cloudFront || {}
    if (userConfig.cachePolicies) {
      const Resources =
        this.serverless.service.provider.compiledCloudFormationTemplate
          .Resources
      for (const [name, cachePolicyConfig] of Object.entries(
        userConfig.cachePolicies,
      )) {
        this.cachePolicies.add(name)

        Object.assign(Resources, {
          [this.provider.naming.getCloudFrontCachePolicyLogicalId(name)]: {
            Type: 'AWS::CloudFront::CachePolicy',
            Properties: {
              CachePolicyConfig: {
                ...cachePolicyConfig,
                Name: this.provider.naming.getCloudFrontCachePolicyName(name),
              },
            },
          },
        })
      }
    }
  }

  compileCloudFrontEvents() {
    this.cloudFrontDistributionLogicalId =
      this.provider.naming.getCloudFrontDistributionLogicalId()

    this.cloudFrontDistributionDomainNameLogicalId =
      this.provider.naming.getCloudFrontDistributionDomainNameLogicalId()

    const lambdaAtEdgeFunctions = []

    const origins = []
    const behaviors = []
    let defaultOrigin

    const Resources =
      this.serverless.service.provider.compiledCloudFormationTemplate.Resources
    const Outputs =
      this.serverless.service.provider.compiledCloudFormationTemplate.Outputs

    // helper function for joining origins and behaviors
    function extendDeep(object, source) {
      return _.assignWith(object, source, (a, b) => {
        if (Array.isArray(a)) {
          return _.uniqWith(a.concat(b), _.isEqual)
        }
        if (_.isObject(a)) {
          extendDeep(a, b)
        }
        return a
      })
    }

    function createOrigin(origin, naming) {
      const originObj = {}
      if (typeof origin === 'string') {
        const originUrl = url.parse(origin)
        Object.assign(originObj, {
          DomainName: originUrl.hostname,
        })

        if (originUrl.pathname && originUrl.pathname.length > 1) {
          Object.assign(originObj, { OriginPath: originUrl.pathname })
        }

        if (originUrl.protocol === 's3:') {
          Object.assign(originObj, { S3OriginConfig: {} })
        } else {
          Object.assign(originObj, {
            CustomOriginConfig: {
              OriginProtocolPolicy: 'match-viewer',
            },
          })
        }
      } else {
        Object.assign(originObj, origin)
      }

      Object.assign(originObj, {
        Id: naming.getCloudFrontOriginId(originObj),
      })
      return originObj
    }

    const unusedUserDefinedCachePolicies = new Set(this.cachePolicies)
    this.serverless.service.getAllFunctions().forEach((functionName) => {
      const functionObj = this.serverless.service.getFunction(functionName)
      if (functionObj.events) {
        functionObj.events.forEach((event) => {
          if (event.cloudFront) {
            const lambdaFunctionLogicalId = Object.keys(Resources).find(
              (key) =>
                Resources[key].Type === 'AWS::Lambda::Function' &&
                Resources[key].Properties.FunctionName === functionObj.name,
            )

            // Remove VPC & Env vars from lambda@Edge
            delete Resources[lambdaFunctionLogicalId].Properties.VpcConfig
            delete Resources[lambdaFunctionLogicalId].Properties.Environment

            // Retain Lambda@Edge functions to avoid issues when removing the CloudFormation stack
            Object.assign(Resources[lambdaFunctionLogicalId], {
              DeletionPolicy: 'Retain',
            })

            const lambdaVersionLogicalId = Object.keys(Resources).find(
              (key) => {
                const resource = Resources[key]
                if (resource.Type !== 'AWS::Lambda::Version') return false
                return (
                  _.get(resource, 'Properties.FunctionName.Ref') ===
                  lambdaFunctionLogicalId
                )
              },
            )

            const pathPattern =
              typeof event.cloudFront.pathPattern === 'string'
                ? event.cloudFront.pathPattern
                : undefined

            let origin = createOrigin(
              event.cloudFront.origin,
              this.provider.naming,
            )
            const existingOrigin = origins.find((o) => o.Id === origin.Id)

            if (!existingOrigin) {
              origins.push(origin)
            } else {
              origin = extendDeep(existingOrigin, origin)
            }

            if (event.cloudFront.isDefaultOrigin) {
              if (defaultOrigin && defaultOrigin !== origin) {
                throw new ServerlessError(
                  'Found more than one cloudfront event with "isDefaultOrigin" defined',
                  'CLOUDFRONT_MULTIPLE_DEFAULT_ORIGIN_EVENTS',
                )
              }
              defaultOrigin = origin
            }

            let behavior = {
              ViewerProtocolPolicy: 'allow-all',
            }
            let shouldAssignCachePolicy = true
            if (event.cloudFront.behavior) {
              Object.assign(behavior, event.cloudFront.behavior)
            }

            if (
              event.cloudFront.behavior &&
              event.cloudFront.behavior.CachePolicyId
            ) {
              Object.assign(behavior, {
                CachePolicyId: event.cloudFront.behavior.CachePolicyId,
              })
              shouldAssignCachePolicy = false
            }

            if (
              event.cloudFront.behavior &&
              (event.cloudFront.behavior.ForwardedValues ||
                event.cloudFront.behavior.MaxTTL != null ||
                event.cloudFront.behavior.MinTTL != null ||
                event.cloudFront.behavior.DefaultTTL != null)
            ) {
              shouldAssignCachePolicy = false
            }

            if (event.cloudFront.cachePolicy) {
              const { id, name } = event.cloudFront.cachePolicy
              if (name) {
                if (!this.cachePolicies.has(name)) {
                  throw new ServerlessError(
                    `Event references not configured cache policy '${name}'`,
                    'UNRECOGNIZED_CLOUDFRONT_CACHE_POLICY',
                  )
                }
                unusedUserDefinedCachePolicies.delete(name)
              }
              Object.assign(behavior, {
                CachePolicyId: id || {
                  Ref: this.provider.naming.getCloudFrontCachePolicyLogicalId(
                    name,
                  ),
                },
              })
              shouldAssignCachePolicy = false
            }

            // Assigning default cache policy only if cache policy reference is not defined.
            if (shouldAssignCachePolicy) {
              // Assigning default Managed-CachingOptimized Cache Policy.
              // See details at https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html#managed-cache-policies-list
              Object.assign(behavior, {
                CachePolicyId: '658327ea-f89d-4fab-a63d-7e88639e58f6',
              })
            }

            const lambdaFunctionAssociation = {
              EventType: event.cloudFront.eventType,
              LambdaFunctionARN: {
                Ref: lambdaVersionLogicalId,
              },
            }

            if (event.cloudFront.includeBody != null) {
              lambdaFunctionAssociation.IncludeBody =
                event.cloudFront.includeBody
            }

            Object.assign(behavior, {
              TargetOriginId: origin.Id,
              LambdaFunctionAssociations: [lambdaFunctionAssociation],
            })

            if (pathPattern) {
              Object.assign(behavior, { PathPattern: pathPattern })
            }

            const existingBehaviour = behaviors.find(
              (o) =>
                o.PathPattern === behavior.PathPattern &&
                o.TargetOriginId === behavior.TargetOriginId,
            )

            if (!existingBehaviour) {
              behaviors.push(behavior)
            } else {
              behavior = extendDeep(existingBehaviour, behavior)
            }

            lambdaAtEdgeFunctions.push(
              Object.assign({}, functionObj, {
                functionName,
                lambdaVersionLogicalId,
              }),
            )
          }
        })
      }
    })

    unusedUserDefinedCachePolicies.forEach((unusedUserDefinedCachePolicy) => {
      log.warning(
        `Setting "provider.cloudFront.cachePolicies.${unusedUserDefinedCachePolicy}" is not used by any cloudFront event configuration.`,
      )
    })

    // sort that first is without PathPattern if available
    behaviors.sort((a, b) => {
      if (a.PathPattern && !b.PathPattern) {
        return 1
      }
      if (b.PathPattern && !a.PathPattern) {
        return -1
      }
      return 0
    })

    if (lambdaAtEdgeFunctions.length) {
      if (this.provider.getRegion() !== 'us-east-1') {
        throw new ServerlessError(
          'CloudFront associated functions have to be deployed to the us-east-1 region.',
          'CLOUDFRONT_INVALID_REGION',
        )
      }

      // Check if all behaviors got unique pathPatterns
      if (behaviors.length !== _.uniqBy(behaviors, 'PathPattern').length) {
        throw new ServerlessError(
          'Found more than one behavior with the same PathPattern',
          'CLOUDFRONT_MULTIPLE_BEHAVIORS_FOR_SINGLE_PATH_PATTERN',
        )
      }

      // Check if all event types in every behavior is unique
      if (
        behaviors.some((o) => {
          return (
            o.LambdaFunctionAssociations.length !==
            _.uniqBy(o.LambdaFunctionAssociations, 'EventType').length
          )
        })
      ) {
        throw new ServerlessError(
          'The event type of a function association must be unique in the cache behavior',
          'CLOUDFRONT_EVENT_TYPE_NON_UNIQUE_CACHE_BEHAVIOR',
        )
      }

      // DefaultCacheBehavior does not support PathPattern property
      if (behaviors[0].PathPattern) {
        let origin = defaultOrigin
        if (!origin) {
          if (origins.length > 1) {
            throw new ServerlessError(
              'Found more than one origin but none of the cloudfront event has "isDefaultOrigin" defined',
              'CLOUDFRONT_MULTIPLE_DEFAULT_ORIGIN_EVENTS',
            )
          }
          origin = origins[0]
        }
        const behavior = _.omit(behaviors[0], [
          'PathPattern',
          'LambdaFunctionAssociations',
        ])
        behavior.TargetOriginId = origin.Id
        behaviors.unshift(behavior)
      }

      const lambdaInvokePermissions = lambdaAtEdgeFunctions.reduce(
        (permissions, lambdaAtEdgeFunction) => {
          const invokePermissionName =
            this.provider.naming.getLambdaAtEdgeInvokePermissionLogicalId(
              lambdaAtEdgeFunction.functionName,
            )
          const invokePermission = {
            Type: 'AWS::Lambda::Permission',
            Properties: {
              FunctionName: {
                Ref: lambdaAtEdgeFunction.lambdaVersionLogicalId,
              },
              Action: 'lambda:InvokeFunction',
              Principal: 'edgelambda.amazonaws.com',
              SourceArn: {
                'Fn::Join': [
                  '',
                  [
                    '',
                    'arn:',
                    { Ref: 'AWS::Partition' },
                    ':cloudfront::',
                    { Ref: 'AWS::AccountId' },
                    ':distribution/',
                    {
                      Ref: this.provider.naming.getCloudFrontDistributionLogicalId(),
                    },
                  ],
                ],
              },
            },
          }
          return Object.assign(permissions, {
            [invokePermissionName]: invokePermission,
          })
        },
        {},
      )

      Object.assign(Resources, lambdaInvokePermissions)

      if (!Resources.IamRoleLambdaExecution) {
        log.notice(
          `Remember to add required lambda@edge permissions to your execution role. Documentation: ${style.link(
            'https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-edge-permissions.html',
          )}`,
        )
      } else {
        const lambdaAssumeStatement =
          Resources.IamRoleLambdaExecution.Properties.AssumeRolePolicyDocument.Statement.find(
            (statement) =>
              statement.Principal.Service.includes('lambda.amazonaws.com'),
          )
        if (lambdaAssumeStatement) {
          lambdaAssumeStatement.Principal.Service.push(
            'edgelambda.amazonaws.com',
          )
        }

        // Lambda creates CloudWatch Logs log streams
        // in the CloudWatch Logs regions closest
        // to the locations where the function is executed.
        // The format of the name for each log stream is
        // /aws/lambda/us-east-1.function-name where
        // function-name is the name that you gave
        // to the function when you created it.
        Resources.IamRoleLambdaExecution.Properties.Policies[0].PolicyDocument.Statement.push(
          {
            Effect: 'Allow',
            Action: [
              'logs:CreateLogGroup',
              'logs:CreateLogStream',
              'logs:PutLogEvents',
              'logs:TagResource',
            ],
            Resource: [{ 'Fn::Sub': 'arn:${AWS::Partition}:logs:*:*:*' }],
          },
        )
      }

      const CacheBehaviors = behaviors.slice(1)

      const CloudFrontDistribution = {
        Type: 'AWS::CloudFront::Distribution',
        Properties: {
          DistributionConfig: {
            Comment: `${
              this.serverless.service.service
            } ${this.provider.getStage()}`,
            Enabled: true,
            DefaultCacheBehavior: behaviors[0],
            Origins: origins,
          },
        },
      }

      if (CacheBehaviors.length > 0) {
        Object.assign(CloudFrontDistribution.Properties.DistributionConfig, {
          CacheBehaviors,
        })
      }

      Object.assign(Resources, {
        [this.cloudFrontDistributionLogicalId]: CloudFrontDistribution,
      })

      _.merge(Outputs, {
        [this.cloudFrontDistributionLogicalId]: {
          Description: 'CloudFront Distribution Id',
          Value: {
            Ref: this.provider.naming.getCloudFrontDistributionLogicalId(),
          },
        },
        [this.cloudFrontDistributionDomainNameLogicalId]: {
          Description: 'CloudFront Distribution Domain Name',
          Value: {
            'Fn::GetAtt': [
              this.provider.naming.getCloudFrontDistributionLogicalId(),
              'DomainName',
            ],
          },
        },
      })
    }
  }
}

export default AwsCompileCloudFrontEvents
